HTTP 缓存
•
# HTTP 缓存
本文结合 RFC9111[^1] 和前端使用场景概括 HTTP 缓存的原理及控制。
## 概述
RFC9111 中对 HTTP 缓存主要的作用描述为减少未来等效请求的响应时间和网络带宽消耗。尽管这是一个可选项的功能,但可以假设重用缓存响应是可取的,并且在没有要求或本地配置阻止的情况下,这种重用是默认行为。[^1]
### 两种缓存类型
响应中的 `Cache-Control` 控制响应可以在哪些场景下被缓存。
- `Cache-Control: max-age=31536000` 默认,公共缓存会排除带有 Authorization 头的响应
- `Cache-Control: public` 任意缓存,带 Authorization 头也允许被缓存
- `Cache-Control: private` 私有缓存,只缓存在客户端
#### Shared Cache
共享缓存。存储响应以供多个用户重用的缓存,比如 CDN。
#### Private Cache
私有缓存。专门为单个用户服务的的缓存,比如浏览器缓存。
### Date
响应在源服务端生成的时间。
```http
HTTP/1.1 200 OK
Date: Tue, 22 Feb 2022 22:00:00 GMT
```
### Age
响应由源服务器生成或成功验证以来所经过的时间。
```http
HTTP/1.1 200 OK
Cache-Control: max-age=604800
Age: 86400
%% 收到该响应的客户端会认为它在剩余的 518400 秒内是有效的。 %%
```
Age 存在的意义是, 计算时会取`本地时间 - Date`与`上游返回的 Age`中的较大则。
### 新鲜度(Freshness)
- `Fresh` 新鲜
- `Stale` 过时
主要由服务端显示提供过期时间,并根据缓存的响应是否超出来判断。当服务端未显示提供过期时间,也可以使用[启发式缓存](#启发式缓存(Heuristic%20Expiration%20Time))来确定过期时间。
### 缓存 Key
Cache Key 主要由请求方法和请求 URI 组成
## 使用缓存
### 优先级
Vary > Cache-Control > Expires > ETag > Last-Modified
### Vary
为了区分针对内容协商的请求返回的响应不同,响应头中可以包含一个 Vary 字段,用于说明响应的内容与请求头中的那些字段有关。
例如:
- 同一个 URL `/index.html`
- 若 `Accept-Encoding: gzip`,则返回 gzip 压缩版
- 若 `Accept-Encoding: identity`,则返回未压缩版
这两个版本都可能被缓存。如果没有区分依据,缓存系统会错误地把 gzip 版返回给不支持 gzip 的客户端。
所以服务器会加上:
```http
HTTP/1.1 200 OK
Vary: Accept-Encoding
```
表示:
> 缓存内容取决于请求头 `Accept-Encoding`。
> 当下次请求该 URL 时,缓存系统要检查该请求头是否一致,只有一致时才算命中缓存。
### 显式过期时间(Explicit Expiration Time)
#### Cache-Control
```http
HTTP/1.1 200 OK
Cache-Control: max-age=604800, immutable
```
常见策略
- public 强制共享缓存,哪怕带了 Authorization
- private 缓存仅存在于客户端
- s-maxage 共享缓存时间
- max-age 缓存时间
- no-cache 每次使用前必须验证
- no-store 不缓存,不建议设置,会导丢失致浏览器前进后退的缓存
- immutable 资源不可变,避免重新验证
#### Expires
Expires 指定一个具体的过期时间。由于时间格式难解析,且客户端系统时间可能会有误差,已很少使用。
```http
HTTP/1.1 200 OK
Expires: Tue, 28 Feb 2022 22:22:22 GMT
```
### 启发式缓存(Heuristic Expiration Time)
即使没有任何缓存控制有关的标识,浏览器为了尽可能多的复用缓存
#### 验证过期响应(Revalidation)
过时的响应不会立即丢弃,可以进行重新验证,当数据没有发生变化,服务端可以返回 304 继续使用客户端缓存,并更新缓存有效期。
```http
HTTP/1.1 304 Not Modified
Last-Modified: Tue, 22 Feb 2022 22:00:00 GMT
Cache-Control: max-age=3600
```
#### 协商(Conditional Requests)
国内面试时常描述为协商缓存 Ï
##### ETag
###### 响应 ETag
服务器生成资源标识的任何值,一般是资源的 Hash 或版本号。
```http
HTTP/1.1 200 OK
ETag: 123456
```
###### 验证 If-None-Match
验证时,请求会在请求头的`If-None-Match`带上缓存响应中的 ETag。
```http
GET /foo HTTP/1.1
If-None-Match: 123456
```
##### Last-Modified
服务器上资源修改的时间。
###### 响应 Last-Modified
```http
HTTP/1.1 200 OK
Last-Modified: Tue, 22 Feb 2022 22:00:00 GMT
```
###### 验证 If-Modified-Since
验证时,请求会在请求头的`If-Modified-Since`带上缓存响应中的 Last-Modified。
```http
GET /foo HTTP/1.1
If-Modified-Since: Tue, 22 Feb 2022 22:00:00 GMT
```
## 与浏览器结合
### 请求过程
Fetch -> Service Worker -> Memory Cache -> Disk Cache -> CDN Cache -> Server
#### 浏览器上的缓存位置[^2]
- Service Worker - Service Worker 命中缓存,或在 Service Worker 中调用
- Memory Cache - 未配置缓存、preload
- Disk Cache - 明确缓存
- Push Cache - 已被废弃(HTTP/3 移除)
### Service Work 中的缓存
Service 可以通过拦截请求,手动从自己构建的 cache 中返回响应或调用默认 fetch。
```javascript
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cacheRes) => {
return cacheRes || fetch(event.request)
})
)
})
```
### 分区缓存(Cache Partitioning)
为了安全考虑,大部分现代浏览器对 HTTP 请求进行分区缓存,两个网站请求同一个资源,可能无法命中同一个缓存。
部分浏览器分区缓存的规则:[^3]
- **Chrome**: Uses top-level scheme://eTLD+1 and frame scheme://eTLD+1
- **Safari**: Uses [top-level eTLD+1](https://webkit.org/blog/8613/intelligent-tracking-prevention-2-1/)
- **Firefox**: [Planning to implement](https://bugzilla.mozilla.org/show_bug.cgi?id=1536058) with top-level scheme://eTLD+1 and considering including a second key like Chrome
## 一些问题
### 什么缓存也没设置也会有缓存
默认会有启发式缓存。
如果有 Last-Modified,可能会存储 `(请求时间 - Last-Modified) * 10%` 的时间。
### Last-Modified 或 Etag 有修改是否代表文件内容一定有变更
不能。
由于 Nginx 中 ETag 的值为`${Last-Modified}-${Content-Length}`,Last-Modified 时间单位为秒级,如果在极短时间内文件发生改变,文件内容改变但是长度不变,ETag 没有变化[^4]。
### 两个不同的网站访问同一份资源,CDN 缓存了 Access-Control-Allow-Origin 导致跨域失败
A 网站访问后, 源站返回 Access-Control-Allow-Origin: A 被 CDN 缓存。导致 B 网站发起跨域请求时,Access-Control-Allow-Origin 仍然为 A,跨域失败。
源站可以通过设置响应头 [Vary](#vary) 字段, `Vary: Origin` ,将 Origin 也作为缓存条件的依据。
### 两个不同网站访问同一份资源,A 网站访问后,B 网站再访问会命中缓存么
根据[分区缓存](#分区缓存(Cache%20Partitioning)),可能无法命中。
## 结论
在大部分场景下,响应应该通过 Cache-Control 显示声明缓存,并同时提供 ETag 以便在缓存时效时向服务端发起验证。由于 Last-Modified 是一个标准 HTTP 标头,CMS 可能会用来显示时间、爬虫调整爬取频率,所以建议同时提供。
## 扩展阅读
- [MDN. HTTP 缓存. https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Caching](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Caching)
- [MDN. Cache-Control header. https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control)
- [RFC 7234. Hypertext Transfer Protocol (HTTP/1.1): Caching - Obsoleted by: 9111. https://www.rfc-editor.org/rfc/rfc7234.html](https://www.rfc-editor.org/rfc/rfc7234.html)
- [Harry Roberts. Cache-Control for Civilians. https://csswizardry.com/2019/03/cache-control-for-civilians](https://csswizardry.com/2019/03/cache-control-for-civilians)
- [Harry Roberts. What Is the Maximum max-age. https://csswizardry.com/2023/10/what-is-the-maximum-max-age](https://csswizardry.com/2023/10/what-is-the-maximum-max-age)
- [山月. http 服务中静态文件的 Last-Modified 是根据什么生成的. https://q.shanyue.tech/fe/http/117](https://q.shanyue.tech/fe/http/117)
[^1]: [RFC 9111. HTTP Caching](https://www.rfc-editor.org/rfc/rfc9111.html)
[^2]: [浪里行舟. 深入理解浏览器的缓存机制. https://www.jianshu.com/p/54cc04190252](https://www.jianshu.com/p/54cc04190252)
[^3]: [Eiji Kitamura. Gaining security and privacy by partitioning the cache. Chrome for Developers. https://developer.chrome.com/blog/http-cache-partitioning](https://developer.chrome.com/blog/http-cache-partitioning)
[^4]: [山月. http 响应头中的 ETag 值是如何生成的. https://q.shanyue.tech/fe/http/112](https://q.shanyue.tech/fe/http/112)